From 30ce8dd27e344062ca504d1fbaf84ba7dc05d122 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Thu, 28 May 2026 10:37:31 -0400 Subject: [PATCH] add two-pass soft homing for X/Y and skip rehome on subsequent loading position presses --- software/control/_def.py | 11 +++ software/control/core/navigation.py | 79 +++++++++++++++++++--- software/control/gui_hcs.py | 5 +- software/control/microcontroller.py | 101 +++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 11 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 005ae6660..1214dc00f 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -1075,6 +1075,17 @@ class MachineConfiguration: MAX_ACCELERATION_Y_mm:float = 500.0 MAX_ACCELERATION_Z_mm:float = 100.0 + # Soft (two-pass) homing for X and Y. The firmware's homing speed is + # HOMING_VELOCITY_{X,Y} * MAX_VELOCITY_{X,Y}_mm, so we temporarily lower + # MAX_VELOCITY_{X,Y}_mm to these values for the precise second pass. + # Run-of-the-mill mechanical limit switches have speed-dependent trip + # hysteresis; the slow second pass eliminates run-to-run jitter. + SOFT_HOMING_VELOCITY_X_mm:float = 4.0 + SOFT_HOMING_VELOCITY_Y_mm:float = 4.0 + SOFT_HOMING_ACCELERATION_X_mm:float = 100.0 + SOFT_HOMING_ACCELERATION_Y_mm:float = 100.0 + SOFT_HOMING_BACK_OFF_MM:float = 3.0 + # end of actuator specific configurations SCAN_STABILIZATION_TIME_MS_X:float = 160.0 diff --git a/software/control/core/navigation.py b/software/control/core/navigation.py index dbcc804d6..7b581fba2 100644 --- a/software/control/core/navigation.py +++ b/software/control/core/navigation.py @@ -46,6 +46,18 @@ def __init__(self, self.microcontroller.set_callback(self.update_pos) self.is_in_loading_position:bool=False + # Record the microcontroller connection session_id at the time of the + # most recent successful homing. A subsequent reconnect bumps the MCU's + # session_id, so comparing the two tells us whether the firmware + # position frame is still the one we homed against. -1 means "never + # homed in this session". + self._homed_at_session_id:int=-1 + + @property + def has_been_homed(self)->bool: + """True iff the stage was homed in this session AND the firmware + position frame is still valid (no microcontroller reconnect since).""" + return self._homed_at_session_id == self.microcontroller.connection_session_id and self._homed_at_session_id >= 0 @property def plate_type(self)->WellplateFormatPhysical: @@ -222,13 +234,50 @@ def update_pos(self,microcontroller:microcontroller.Microcontroller): #def home_theta(self): # self.microcontroller.home_theta() - def loading_position_enter(self,home_x:bool=True,home_y:bool=True,home_z:bool=True): + def loading_position_enter(self,home_x:bool=True,home_y:bool=True,home_z:bool=True,*,with_homing:bool=True): + """Move the stage to the loading position. + + with_homing=True (default): run the full homing sequence (Z retracted + first, then a two-pass soft home of Y and X). Use this on startup or + whenever the firmware position frame may be stale. + + with_homing=False: skip homing and move via absolute coordinates. + Requires a prior successful home in the same microcontroller session + (raises RuntimeError otherwise). + + home_x/home_y/home_z are legacy per-axis flags retained for the + existing startup-config call in core/__init__.py:587. They behave as + an all-or-nothing switch: the full XY homing only runs when all three + are True; home_z=False short-circuits the whole function. Prefer + with_homing for new callers. + """ # if used through GUI, this should never be the case # but the API must account for this function being called twice if self.is_in_loading_position: MAIN_LOG.log("tried to enter loading position when already in loading position") return + if not with_homing: + # Move to the loading position using absolute moves, without re-homing. + # The firmware position counter MUST already be valid; otherwise + # move_z_to(0) can drive the objective into the plate. Refuse the + # call if we have no record of a valid prior home in this session. + if not self.has_been_homed: + raise RuntimeError( + "loading_position_enter(with_homing=False) requires a prior " + "soft_home in this microcontroller session — the firmware " + "position frame is not known to be valid." + ) + # Z retracts first so the objective clears the sample, then Y and X + # move to the home corner. + self.move_z_to(0.0, wait_for_completion={'timeout_limit_s':10, 'time_step':0.005}) + self.is_in_loading_position=True + MAIN_LOG.log('no-homing - objective retracted') + self.move_y_to(0.0, wait_for_completion={'timeout_limit_s':30, 'time_step':0.005}) + self.move_x_to(0.0, wait_for_completion={'timeout_limit_s':30, 'time_step':0.005}) + MAIN_LOG.log("no-homing - in loading position") + return + if home_z: # retract the objective self.microcontroller.home_z() @@ -240,17 +289,29 @@ def loading_position_enter(self,home_x:bool=True,home_y:bool=True,home_z:bool=Tr MAIN_LOG.log('homing - objective retracted') if home_z and home_y and home_x: + # Snapshot the session_id before any XY motion. wait_till_operation_is_completed + # can autorecover from timeouts by calling attempt_connection() (which bumps + # session_id); if that happens mid-sequence we don't trust the resulting frame + # and force the next attempt to re-home. + session_id_before_xy_homing = self.microcontroller.connection_session_id + # for the new design, need to home y before home x; x also needs to be at > + 10 mm when homing y self.move_x(12.0) self.microcontroller.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='x moving timeout, the program will exit') - - self.microcontroller.home_y() - self.microcontroller.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='y homing timeout, the program will exit') - - self.microcontroller.home_x() - self.microcontroller.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='x homing timeout, the program will exit') - - MAIN_LOG.log("homing - in loading position") + + # Two-pass soft homing: coarse home at MAX_VELOCITY, back off, then a slow + # second pass for a repeatable trip point (limit-switch trip varies with + # approach speed, which was the source of plate-to-plate calibration drift). + self.microcontroller.soft_home_y() + self.microcontroller.soft_home_x() + + if self.microcontroller.connection_session_id == session_id_before_xy_homing: + self._homed_at_session_id = session_id_before_xy_homing + MAIN_LOG.log("homing - in loading position") + else: + # _homed_at_session_id intentionally left unchanged: next GUI press will + # see has_been_homed=False and run the full homing path again. + MAIN_LOG.log("warning - microcontroller reconnected during XY homing; has_been_homed left invalid so next attempt re-homes") def loading_position_leave(self,home_x:bool=True,home_y:bool=True,home_z:bool=True): if not self.is_in_loading_position: diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index b778b1785..f33a34524 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -473,7 +473,10 @@ def loading_position_toggle(self,loading_position_enter:bool): """ if loading_position_enter: # entering loading position self.set_all_interactible_enabled(set_enabled=False,exceptions=[self.position_widget.btn_goToLoadingPosition]) # disable everything except the single button that can leave the loading position - self.core.navigation.loading_position_enter() + # First press of the session homes; subsequent presses move directly + # via absolute coordinates so the limit-switch trip point is only + # exercised once per session (avoids plate-to-plate calibration drift). + self.core.navigation.loading_position_enter(with_homing=not self.core.navigation.has_been_homed) else: # leaving loading position self.core.navigation.loading_position_leave() diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 2a7454322..c18149f2e 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -81,6 +81,22 @@ def __init__(self,version:ControllerType=ControllerType.DUE,sn:Optional[str]=Non self.has_been_initialized_at_least_once=False + # increments on every successful (re)connection; NavigationController + # uses it to invalidate has_been_homed when the firmware position frame + # is no longer valid (a microcontroller reboot resets its counter). + self.connection_session_id:int = 0 + + # Python-side mirror of the per-axis MAX_VELOCITY/ACCELERATION currently + # programmed into the firmware. Kept in sync by set_max_velocity_acceleration + # so soft_home_* can restore the *actually-active* values instead of the + # MACHINE_CONFIG defaults (which may have been overridden by other code). + self._max_velocity_x_mm:float = float(MACHINE_CONFIG.MAX_VELOCITY_X_mm) + self._max_velocity_y_mm:float = float(MACHINE_CONFIG.MAX_VELOCITY_Y_mm) + self._max_velocity_z_mm:float = float(MACHINE_CONFIG.MAX_VELOCITY_Z_mm) + self._max_acceleration_x_mm:float = float(MACHINE_CONFIG.MAX_ACCELERATION_X_mm) + self._max_acceleration_y_mm:float = float(MACHINE_CONFIG.MAX_ACCELERATION_Y_mm) + self._max_acceleration_z_mm:float = float(MACHINE_CONFIG.MAX_ACCELERATION_Z_mm) + self.attempt_connection() self.new_packet_callback_external = None @@ -141,7 +157,11 @@ def attempt_connection(self)->bool: MAIN_LOG.log('controller reconnected') self.has_been_initialized_at_least_once=True - + # New connection means the firmware position counter restarted at 0 + # (in whatever physical pose the stage happens to be in). Bump the + # session id so anything depending on a prior home invalidates itself. + self.connection_session_id += 1 + return True def close(self): @@ -445,6 +465,74 @@ def home_xy(self): cmd[4] = int((MACHINE_CONFIG.STAGE_MOVEMENT_SIGN_Y+1)/2) # "move backward" if SIGN is 1, "move forward" if SIGN is -1 self.send_command(cmd) + # The firmware computes homing speed as HOMING_VELOCITY_{X,Y} * MAX_VELOCITY_{X,Y}_mm, + # and MAX_VELOCITY_{X,Y}_mm is mutable from the host. soft_home_* does a coarse home + # at the current MAX_VELOCITY, backs off, then re-homes at SOFT_HOMING_VELOCITY for a + # repeatable trip point that doesn't depend on approach speed. + + def soft_home_x(self): + # snapshot the active firmware MAX_VELOCITY/ACCELERATION for X so the + # finally block restores whatever was actually programmed, not the + # MACHINE_CONFIG default (which may have been overridden elsewhere). + saved_v_x, saved_a_x = self._max_velocity_x_mm, self._max_acceleration_x_mm + self.home_x() + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='x soft-home (coarse) timeout, the program will exit') + # back off so the second pass actually traverses the switch trip point + self.move_x_usteps(self.mm_to_ustep_x(MACHINE_CONFIG.SOFT_HOMING_BACK_OFF_MM)) + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='x soft-home (back-off) timeout, the program will exit') + # try covers everything that runs after MAX_VELOCITY is lowered, so a + # timeout on any intermediate wait still restores the original value. + try: + # lower MAX_VELOCITY so HOMING_VELOCITY_X * MAX_VELOCITY_X_mm becomes gentle + self.set_max_velocity_acceleration(AXIS.X, MACHINE_CONFIG.SOFT_HOMING_VELOCITY_X_mm, MACHINE_CONFIG.SOFT_HOMING_ACCELERATION_X_mm) + self.wait_till_operation_is_completed() + self.home_x() + self.wait_till_operation_is_completed(30, time_step=0.005, timeout_msg='x soft-home (fine) timeout, the program will exit') + finally: + self.set_max_velocity_acceleration(AXIS.X, saved_v_x, saved_a_x) + self.wait_till_operation_is_completed() + + def soft_home_y(self): + saved_v_y, saved_a_y = self._max_velocity_y_mm, self._max_acceleration_y_mm + self.home_y() + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='y soft-home (coarse) timeout, the program will exit') + self.move_y_usteps(self.mm_to_ustep_y(MACHINE_CONFIG.SOFT_HOMING_BACK_OFF_MM)) + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='y soft-home (back-off) timeout, the program will exit') + try: + self.set_max_velocity_acceleration(AXIS.Y, MACHINE_CONFIG.SOFT_HOMING_VELOCITY_Y_mm, MACHINE_CONFIG.SOFT_HOMING_ACCELERATION_Y_mm) + self.wait_till_operation_is_completed() + self.home_y() + self.wait_till_operation_is_completed(30, time_step=0.005, timeout_msg='y soft-home (fine) timeout, the program will exit') + finally: + self.set_max_velocity_acceleration(AXIS.Y, saved_v_y, saved_a_y) + self.wait_till_operation_is_completed() + + def soft_home_xy(self): + saved_v_x, saved_a_x = self._max_velocity_x_mm, self._max_acceleration_x_mm + saved_v_y, saved_a_y = self._max_velocity_y_mm, self._max_acceleration_y_mm + # coarse simultaneous home + self.home_xy() + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='xy soft-home (coarse) timeout, the program will exit') + # back off both axes + self.move_x_usteps(self.mm_to_ustep_x(MACHINE_CONFIG.SOFT_HOMING_BACK_OFF_MM)) + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='x soft-home (back-off) timeout, the program will exit') + self.move_y_usteps(self.mm_to_ustep_y(MACHINE_CONFIG.SOFT_HOMING_BACK_OFF_MM)) + self.wait_till_operation_is_completed(10, time_step=0.005, timeout_msg='y soft-home (back-off) timeout, the program will exit') + try: + # lower both MAX_VELOCITY; try covers from the first lower onward so + # an X-only lower (with a subsequent Y-lower failure) still restores. + self.set_max_velocity_acceleration(AXIS.X, MACHINE_CONFIG.SOFT_HOMING_VELOCITY_X_mm, MACHINE_CONFIG.SOFT_HOMING_ACCELERATION_X_mm) + self.wait_till_operation_is_completed() + self.set_max_velocity_acceleration(AXIS.Y, MACHINE_CONFIG.SOFT_HOMING_VELOCITY_Y_mm, MACHINE_CONFIG.SOFT_HOMING_ACCELERATION_Y_mm) + self.wait_till_operation_is_completed() + self.home_xy() + self.wait_till_operation_is_completed(30, time_step=0.005, timeout_msg='xy soft-home (fine) timeout, the program will exit') + finally: + self.set_max_velocity_acceleration(AXIS.X, saved_v_x, saved_a_x) + self.wait_till_operation_is_completed() + self.set_max_velocity_acceleration(AXIS.Y, saved_v_y, saved_a_y) + self.wait_till_operation_is_completed() + def zero_x(self): cmd = bytearray(self.tx_buffer_length) cmd[1] = CMD_SET.HOME_OR_ZERO @@ -535,6 +623,17 @@ def set_max_velocity_acceleration(self,axis:int,velocity:Union[int,float],accele cmd[5] = int(acceleration*10) >> 8 cmd[6] = int(acceleration*10) & 0xff self.send_command(cmd) + # mirror the value the firmware now has, so soft_home_* and any future + # save/restore-style code can use the *active* value as the baseline. + if axis == AXIS.X: + self._max_velocity_x_mm = float(velocity) + self._max_acceleration_x_mm = float(acceleration) + elif axis == AXIS.Y: + self._max_velocity_y_mm = float(velocity) + self._max_acceleration_y_mm = float(acceleration) + elif axis == AXIS.Z: + self._max_velocity_z_mm = float(velocity) + self._max_acceleration_z_mm = float(acceleration) @TypecheckFunction def set_leadscrew_pitch(self,axis:int,pitch_mm:Union[float,int]):