From a76bb52a421a76c377ac40c5d2b27b2d806e3b70 Mon Sep 17 00:00:00 2001 From: kferjaoui Date: Sun, 14 Dec 2025 14:47:58 +0100 Subject: [PATCH 1/6] feat: serialize TEM commands for backlash-safe fast moves - Add single TEM I/O dispatcher and route fast stage moves through it - Execute preload/overshoot + correction as an atomic sequence (no interleaving with other TEM calls) - Keep existing UI signal wiring and TEMClient dependency unchanged --- .../tem_controls/task/task_manager.py | 143 ++++++++++-------- .../tem_controls/task/tem_dispatcher.py | 77 ++++++++++ 2 files changed, 161 insertions(+), 59 deletions(-) create mode 100644 jungfrau_gui/ui_components/tem_controls/task/tem_dispatcher.py 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..8bd62a6 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) @@ -64,6 +66,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() @@ -749,6 +752,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,71 +765,89 @@ def shutdown(self): logging.error(f'Shutdown of Task Manager triggered error: {e}') pass + def _two_step_sequence(self, axis_fn, value, preload, settle_s=0.05): + """ + 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_with_backlash(self, moverid=0, value=10, backlash=0, button=False, scale=1): + def move_with_backlash(self, moverid=0, value=10.0, preload=0.0, button=False, scale=1.0): """ - Move the stage with backlash correction. - - Args: - moverid: Direction identifier (0-7) - 0,1: +X, -X - 2,3: +Y, -Y - 4,5: +Z, -Z - 6,7: +TX, -TX (tilt) - value: Movement amount - backlash: Backlash correction amount - scale: Scaling factor for movement value + Robust backlash/preload move: + - Computes whether preload should be applied (only on direction change) + - Executes the move as an atomic sequence in the TEMDispatcher thread + - Keeps GUI responsive (no blocking in main thread) """ - # Request current status information + # Ask for fresh status (coalesced, won't spam) QTimer.singleShot(0, lambda: self.send_to_tem("#info", asynchronous=True)) - - # Backlash correction logic - only apply when changing direction - axis = moverid // 2 # Determine which axis (X=0, Y=1, Z=2, TX=3) - direction = moverid % 2 # Determine direction (even=positive, odd=negative) - - # Check if we're continuing in the same direction (no backlash needed) - if direction == 0 and np.sign(self.tem_status["stage.GetPos_diff"][axis]) >= 0: - backlash = 0 - 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]))}, " - f"{self.tem_status['stage.GetPos'][3]:6.2f} {self.tem_status['stage.GetPos_diff'][3]:6.2f}, {backlash}" - ) - - # Get client reference - client = self.client - # Calculate final movement value with backlash compensation + axis = moverid // 2 # X=0, Y=1, Z=2, TX=3 + direction = moverid % 2 # 0=positive, 1=negative + movement_value = value * scale - - # Execute movement in a separate thread based on direction - match moverid: - case 0: # +X - threading.Thread(target=client.SetXRel, args=(movement_value - backlash,)).start() - case 1: # -X - threading.Thread(target=client.SetXRel, args=(movement_value + backlash,)).start() - case 2: # +Y - threading.Thread(target=client.SetYRel, args=(movement_value - backlash,)).start() - case 3: # -Y - threading.Thread(target=client.SetYRel, args=(movement_value + backlash,)).start() - case 4: # +Z - threading.Thread(target=client.SetZRel, args=(movement_value + backlash,)).start() - case 5: # -Z - threading.Thread(target=client.SetZRel, args=(movement_value - backlash,)).start() - case 6: # +TX (tilt) - threading.Thread(target=client.SetTXRel, args=(movement_value + backlash,)).start() - case 7: # -TX (tilt) - threading.Thread(target=client.SetTXRel, args=(movement_value - backlash,)).start() - case _: - logging.warning(f"Undefined moverid {moverid}") - return + last_diff_sign = np.sign(self.tem_status["stage.GetPos_diff"][axis]) + + apply_preload = True + if direction == 0 and last_diff_sign >= 0: + apply_preload = False + elif direction == 1 and last_diff_sign < 0: + apply_preload = False + + effective_preload = float(preload) if (apply_preload and preload != 0) else 0.0 + + client = self.client + + # pick axis function (same as before) + if moverid in (0, 1): + axis_fn = client.SetXRel + elif moverid in (2, 3): + axis_fn = client.SetYRel + elif moverid in (4, 5): + axis_fn = client.SetZRel + elif moverid in (6, 7): + axis_fn = client.SetTXRel + else: + logging.warning(f"Undefined moverid {moverid}") + return + + # 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) - if moverid < 2 and button: # display the previous move to user + # 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;') + 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}") + 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);" + ) 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)) From 79552c5a1796a7f80cf26dc99392bc028c74b70f Mon Sep 17 00:00:00 2001 From: kferjaoui Date: Sun, 14 Dec 2025 14:54:12 +0100 Subject: [PATCH 2/6] fix: update docstring for move_with_backlash() --- .../tem_controls/task/task_manager.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 8bd62a6..6e1bfd7 100644 --- a/jungfrau_gui/ui_components/tem_controls/task/task_manager.py +++ b/jungfrau_gui/ui_components/tem_controls/task/task_manager.py @@ -793,10 +793,19 @@ def _two_step_sequence(self, axis_fn, value, preload, settle_s=0.05): @Slot(int, float, float, bool) def move_with_backlash(self, moverid=0, value=10.0, preload=0.0, button=False, scale=1.0): """ - Robust backlash/preload move: - - Computes whether preload should be applied (only on direction change) - - Executes the move as an atomic sequence in the TEMDispatcher thread - - Keeps GUI responsive (no blocking in main thread) + 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)) From 567b6b0889af85a33155e0b5d51adbba12b019cf Mon Sep 17 00:00:00 2001 From: kferjaoui Date: Sun, 14 Dec 2025 15:10:39 +0100 Subject: [PATCH 3/6] fix f-string --- .../ui_components/tem_controls/task/stage_centering_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From e27e2e5ba355d8b2f1f37837d8a8b5cd2df724be Mon Sep 17 00:00:00 2001 From: kferjaoui Date: Sun, 14 Dec 2025 15:44:18 +0100 Subject: [PATCH 4/6] fix: add preload overshoot for fast moves and keep backlash for fine correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define globals.preload (e.g. +1 µm / +1°) for two-step overshoot+return moves - Wire stage jog buttons and move_with_backlash() calls to use preload consistently - Note: current backlash/overshoot behavior is only applied on negative moves --- jungfrau_gui/globals.py | 3 ++ .../tem_controls/task/stage_centering_task.py | 6 ++-- .../ui_components/tem_controls/tem_action.py | 32 ++++++++++++++----- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/jungfrau_gui/globals.py b/jungfrau_gui/globals.py index 1db1125..23e5233 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 = [1000, 1000, 0, 1] # X,Y,Z in nm (1 µ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 9b642b9..18f6afe 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 @@ -74,9 +74,11 @@ def run(self): 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) + # self.control.trigger_movewithbacklash.emit(np.sign(movexy[0]) > 0, -movexy[0]*globals.UM_TO_NM, globals.backlash[0], False) + self.control.trigger_movewithbacklash.emit(np.sign(movexy[0]) > 0, -movexy[0]*globals.UM_TO_NM, globals.preload[0], False) time.sleep(0.5) - self.control.trigger_movewithbacklash.emit((np.sign(movexy[1]) > 0)+2, -movexy[1]*globals.UM_TO_NM, globals.backlash[1], False) + # self.control.trigger_movewithbacklash.emit((np.sign(movexy[1]) > 0)+2, -movexy[1]*globals.UM_TO_NM, globals.backlash[1], False) + self.control.trigger_movewithbacklash.emit((np.sign(movexy[1]) > 0)+2, -movexy[1]*globals.UM_TO_NM, globals.preload[1], False) time.sleep(0.5) else: if tilt_X_abs < 11: diff --git a/jungfrau_gui/ui_components/tem_controls/tem_action.py b/jungfrau_gui/ui_components/tem_controls/tem_action.py index 9ee91cd..7bf0cd5 100644 --- a/jungfrau_gui/ui_components/tem_controls/tem_action.py +++ b/jungfrau_gui/ui_components/tem_controls/tem_action.py @@ -89,16 +89,22 @@ 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)) + # self.tem_stagectrl.movex10ump.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(0, 10000, globals.backlash[0], True)) + self.tem_stagectrl.movex10ump.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(0, 10000, globals.preload[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)) + # self.tem_stagectrl.movex10umn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(1, -10000, globals.backlash[0], True)) + self.tem_stagectrl.movex10umn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(1, -10000, globals.preload[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)) + # self.tem_stagectrl.move10degp.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(6, 10, globals.backlash[3], False)) + self.tem_stagectrl.move10degp.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(6, 10, globals.preload[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)) + # self.tem_stagectrl.move10degn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(7, -10, globals.backlash[3], False)) + self.tem_stagectrl.move10degn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(7, -10, globals.preload[3], False)) + # 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) @@ -1079,15 +1085,25 @@ def subimageMouseClickEvent(self, event): logging.info("Large movement (> 300 um) is not yet permitted for safety.") return + # if dx >= 0: + # self.control.trigger_movewithbacklash.emit(0, dx, globals.backlash[0], False) + # else: + # self.control.trigger_movewithbacklash.emit(1, dx, globals.backlash[0], False) + # time.sleep(np.abs(dx)/5e4) # assumes speed of movement as > 50 um/s + # if dy >= 0: + # self.control.trigger_movewithbacklash.emit(2, dy, globals.backlash[1], False) + # else: + # self.control.trigger_movewithbacklash.emit(3, dy, globals.backlash[1], False) + if dx >= 0: - self.control.trigger_movewithbacklash.emit(0, dx, globals.backlash[0], False) + self.control.trigger_movewithbacklash.emit(0, dx, globals.preload[0], False) else: - self.control.trigger_movewithbacklash.emit(1, dx, globals.backlash[0], False) + self.control.trigger_movewithbacklash.emit(1, dx, globals.preload[0], False) time.sleep(np.abs(dx)/5e4) # assumes speed of movement as > 50 um/s if dy >= 0: - self.control.trigger_movewithbacklash.emit(2, dy, globals.backlash[1], False) + self.control.trigger_movewithbacklash.emit(2, dy, globals.preload[1], False) else: - self.control.trigger_movewithbacklash.emit(3, dy, globals.backlash[1], False) + self.control.trigger_movewithbacklash.emit(3, dy, globals.preload[1], False) logging.info(f'Move X: {dx/globals.UM_TO_NM:.1f} um, Y: {dy/globals.UM_TO_NM:.1f} um') From dda021cac67b2e6b17efcb5dbe9c459e70f643d7 Mon Sep 17 00:00:00 2001 From: Khalil Daniel Ferjaoui Date: Tue, 20 Jan 2026 15:45:27 +0100 Subject: [PATCH 5/6] Add preload-based parking + stackable Back for fast stage moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implement “parking” movements to reduce backlash: move past target by a preload margin, then step back by preload so gears are loaded in the return direction. For e.g; pushing [+10µm] moves +12µm then −2µm; [−10µm] -> −12µm then +2µm; same idea for ±TX degrees - track a per-axis net “away” displacement (accumulator) so repeated parking movements stack; Back returns in one straight move by −net_away (no preload) and then resets the accumulator. - serialize all TEM ZMQ commands through a single dispatcher lane to avoid overlapping REQ/REP calls and reduce timeouts under polling + user actions TODO: tie "back buttons" status control (enable/disable) + accumulator reset to dispatcher success via Qt queued signal/callback (currently toggled from main thread) --- jungfrau_gui/globals.py | 2 +- .../tem_controls/task/stage_centering_task.py | 6 +- .../tem_controls/task/task_manager.py | 199 +++++++++++++++--- .../ui_components/tem_controls/tem_action.py | 55 ++--- .../tem_controls/ui_tem_specific.py | 14 ++ 5 files changed, 217 insertions(+), 59 deletions(-) diff --git a/jungfrau_gui/globals.py b/jungfrau_gui/globals.py index 23e5233..8786a79 100644 --- a/jungfrau_gui/globals.py +++ b/jungfrau_gui/globals.py @@ -94,4 +94,4 @@ def get_git_info(): backlash = [100, 80, 0, 0] # overshoot/preload used for two-step jog moves -preload = [1000, 1000, 0, 1] # X,Y,Z in nm (1 µm), TX in deg (1°) \ No newline at end of file +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 18f6afe..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 @@ -74,11 +74,9 @@ def run(self): 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) - self.control.trigger_movewithbacklash.emit(np.sign(movexy[0]) > 0, -movexy[0]*globals.UM_TO_NM, globals.preload[0], False) + self.control.trigger_movewithbacklash.emit(np.sign(movexy[0]) > 0, -movexy[0]*globals.UM_TO_NM, globals.backlash[0], False) time.sleep(0.5) - # self.control.trigger_movewithbacklash.emit((np.sign(movexy[1]) > 0)+2, -movexy[1]*globals.UM_TO_NM, globals.backlash[1], False) - self.control.trigger_movewithbacklash.emit((np.sign(movexy[1]) > 0)+2, -movexy[1]*globals.UM_TO_NM, globals.preload[1], False) + self.control.trigger_movewithbacklash.emit((np.sign(movexy[1]) > 0)+2, -movexy[1]*globals.UM_TO_NM, globals.backlash[1], False) time.sleep(0.5) else: if tilt_X_abs < 11: 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 6e1bfd7..f92744a 100644 --- a/jungfrau_gui/ui_components/tem_controls/task/task_manager.py +++ b/jungfrau_gui/ui_components/tem_controls/task/task_manager.py @@ -58,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() @@ -88,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 @@ -765,7 +770,98 @@ def shutdown(self): logging.error(f'Shutdown of Task Manager triggered error: {e}') pass - def _two_step_sequence(self, axis_fn, value, preload, settle_s=0.05): + # 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): + """ + Move the stage with backlash correction. + + Args: + moverid: Direction identifier (0-7) + 0,1: +X, -X + 2,3: +Y, -Y + 4,5: +Z, -Z + 6,7: +TX, -TX (tilt) + value: Movement amount + backlash: Backlash correction amount + scale: Scaling factor for movement value + """ + # Request current status information + QTimer.singleShot(0, lambda: self.send_to_tem("#info", asynchronous=True)) + + # Backlash correction logic - only apply when changing direction + axis = moverid // 2 # Determine which axis (X=0, Y=1, Z=2, TX=3) + direction = moverid % 2 # Determine direction (even=positive, odd=negative) + + # Check if we're continuing in the same direction (no backlash needed) + if direction == 0 and np.sign(self.tem_status["stage.GetPos_diff"][axis]) >= 0: + backlash = 0 + 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/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}" + ) + + # Get client reference + client = self.client + + # Calculate final movement value with backlash compensation + movement_value = value * scale + + # Execute movement in a separate thread based on direction + match moverid: + case 0: # +X + threading.Thread(target=client.SetXRel, args=(movement_value - backlash,)).start() + case 1: # -X + threading.Thread(target=client.SetXRel, args=(movement_value + backlash,)).start() + case 2: # +Y + threading.Thread(target=client.SetYRel, args=(movement_value - backlash,)).start() + case 3: # -Y + threading.Thread(target=client.SetYRel, args=(movement_value + backlash,)).start() + case 4: # +Z + threading.Thread(target=client.SetZRel, args=(movement_value + backlash,)).start() + case 5: # -Z + threading.Thread(target=client.SetZRel, args=(movement_value - backlash,)).start() + case 6: # +TX (tilt) + threading.Thread(target=client.SetTXRel, args=(movement_value + backlash,)).start() + case 7: # -TX (tilt) + threading.Thread(target=client.SetTXRel, args=(movement_value - backlash,)).start() + case _: + logging.warning(f"Undefined moverid {moverid}") + 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/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) @@ -791,7 +887,7 @@ def _two_step_sequence(self, axis_fn, value, preload, settle_s=0.05): @Slot(int, float, float, bool) - def move_with_backlash(self, moverid=0, value=10.0, preload=0.0, button=False, scale=1.0): + 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. @@ -809,40 +905,23 @@ def move_with_backlash(self, moverid=0, value=10.0, preload=0.0, button=False, s """ # Ask for fresh status (coalesced, won't spam) QTimer.singleShot(0, lambda: self.send_to_tem("#info", asynchronous=True)) - - axis = moverid // 2 # X=0, Y=1, Z=2, TX=3 - direction = moverid % 2 # 0=positive, 1=negative - + + axis = moverid // 2 movement_value = value * scale - last_diff_sign = np.sign(self.tem_status["stage.GetPos_diff"][axis]) - - apply_preload = True - if direction == 0 and last_diff_sign >= 0: - apply_preload = False - elif direction == 1 and last_diff_sign < 0: - apply_preload = False + effective_preload = float(preload) #if (apply_preload and preload != 0) else 0.0 - effective_preload = float(preload) if (apply_preload and preload != 0) else 0.0 - - client = self.client + # set longer timeout for slow rotation speeds + MOVE_TIMEOUT_MS = 30000 if axis == 3 else 5000 - # pick axis function (same as before) - if moverid in (0, 1): - axis_fn = client.SetXRel - elif moverid in (2, 3): - axis_fn = client.SetYRel - elif moverid in (4, 5): - axis_fn = client.SetZRel - elif moverid in (6, 7): - axis_fn = client.SetTXRel - else: - logging.warning(f"Undefined moverid {moverid}") - return + # 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: @@ -860,3 +939,67 @@ def move_with_backlash(self, moverid=0, value=10.0, preload=0.0, button=False, s 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/tem_action.py b/jungfrau_gui/ui_components/tem_controls/tem_action.py index 7bf0cd5..e4a1672 100644 --- a/jungfrau_gui/ui_components/tem_controls/tem_action.py +++ b/jungfrau_gui/ui_components/tem_controls/tem_action.py @@ -88,18 +88,31 @@ 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)) - self.tem_stagectrl.movex10ump.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(0, 10000, globals.preload[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)) - self.tem_stagectrl.movex10umn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(1, -10000, globals.preload[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)) - self.tem_stagectrl.move10degp.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(6, 10, globals.preload[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)) - self.tem_stagectrl.move10degn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(7, -10, globals.preload[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( @@ -1085,25 +1098,15 @@ def subimageMouseClickEvent(self, event): logging.info("Large movement (> 300 um) is not yet permitted for safety.") return - # if dx >= 0: - # self.control.trigger_movewithbacklash.emit(0, dx, globals.backlash[0], False) - # else: - # self.control.trigger_movewithbacklash.emit(1, dx, globals.backlash[0], False) - # time.sleep(np.abs(dx)/5e4) # assumes speed of movement as > 50 um/s - # if dy >= 0: - # self.control.trigger_movewithbacklash.emit(2, dy, globals.backlash[1], False) - # else: - # self.control.trigger_movewithbacklash.emit(3, dy, globals.backlash[1], False) - if dx >= 0: - self.control.trigger_movewithbacklash.emit(0, dx, globals.preload[0], False) + self.control.trigger_movewithbacklash.emit(0, dx, globals.backlash[0], False) else: - self.control.trigger_movewithbacklash.emit(1, dx, globals.preload[0], False) + self.control.trigger_movewithbacklash.emit(1, dx, globals.backlash[0], False) time.sleep(np.abs(dx)/5e4) # assumes speed of movement as > 50 um/s if dy >= 0: - self.control.trigger_movewithbacklash.emit(2, dy, globals.preload[1], False) + self.control.trigger_movewithbacklash.emit(2, dy, globals.backlash[1], False) else: - self.control.trigger_movewithbacklash.emit(3, dy, globals.preload[1], False) + self.control.trigger_movewithbacklash.emit(3, dy, globals.backlash[1], False) logging.info(f'Move X: {dx/globals.UM_TO_NM:.1f} um, Y: {dy/globals.UM_TO_NM:.1f} um') 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..0c41c89 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 TX', 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() From 13b135e480f6ce36c86bf254860cc28eecec1258 Mon Sep 17 00:00:00 2001 From: Khalil Daniel Ferjaoui Date: Tue, 20 Jan 2026 17:29:06 +0100 Subject: [PATCH 6/6] ui(tem): rename Back buttons to 'Back (X)' and 'Back (TX)' for clarity --- jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 0c41c89..d004444 100644 --- a/jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py +++ b/jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py @@ -106,8 +106,8 @@ def initUI(self): # 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 TX', self) + 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)