From 563704997c6e715b4a3b2dc240da99a6d05c4608 Mon Sep 17 00:00:00 2001 From: Tim Moerkerken Date: Mon, 2 Feb 2026 23:05:01 +0100 Subject: [PATCH] [feature] Update matrices after changing milling angle [fix] Posture switch button state to match move --- src/odemis/acq/move.py | 49 ++++++----- src/odemis/acq/test/move_tescan_test.py | 43 ++++++++- src/odemis/gui/cont/tabs/fibsem_tab.py | 111 +++++++++++++----------- 3 files changed, 129 insertions(+), 74 deletions(-) diff --git a/src/odemis/acq/move.py b/src/odemis/acq/move.py index 3b38da33ae..9a55f3dbae 100644 --- a/src/odemis/acq/move.py +++ b/src/odemis/acq/move.py @@ -83,7 +83,7 @@ # These values might differ per system and would then require a configuration option per system. # Hardcoded for now. Note that these values correspond to the milling angle, and not the actual stage tilt. -MILLING_RANGE = (5, 30) # degrees +MILLING_RANGE = (math.radians(5), math.radians(30)) def filter_dict(keys: set, d: Dict[str, float]) -> Dict[str, float]: """ @@ -332,7 +332,6 @@ def __init__(self, microscope): md_calib = stage_md.get(model.MD_CALIB, {}) self.pre_tilt = md_calib.get(model.MD_SAMPLE_PRE_TILT, None) self.fib_column_tilt = TFS_FIB_COLUMN_TILT - # use_linked_sem_focus_compensation: when True, the SEM focus is restored to the eucentric # focus when moving the stage. This is done on TFS systems to compensate # for the SEM focus changing when the stage is moved in Z (due to stage linking). @@ -356,6 +355,13 @@ def __init__(self, microscope): mill_md["mill_angle"] = mill_md.pop("rx") self.stage.updateMetadata({model.MD_FAV_MILL_POS_ACTIVE: mill_md}) + # Initialize the milling angle. If not specified in the config, set it to 0 + milling_angle = stage_md.get(model.MD_FAV_MILL_POS_ACTIVE, None) + milling_angle = milling_angle["mill_angle"] if milling_angle else 0 + self.milling_angle = model.FloatContinuous( + milling_angle, (MILLING_RANGE[0], MILLING_RANGE[1]), unit="rad", setter=self._set_milling_angle + ) + # current posture va self.current_posture = model.VigilantAttribute(UNKNOWN) self.stage.position.subscribe(self._update_posture, init=True) @@ -530,6 +536,18 @@ def _transformFromSEMToMeteor(self, pos: Dict[str, float]) -> Dict[str, float]: """ pass + def _set_milling_angle(self, angle: float) -> float: + """ + Set the milling angle of the stage + :param angle: (float) milling angle in radians + """ + if model.MD_FAV_MILL_POS_ACTIVE not in self.stage.getMetadata(): + logging.warning("Trying to set a milling angle on a system that was not configured for milling") + return angle + rotations = {'mill_angle': angle, 'rz': self.stage.getMetadata()[model.MD_FAV_MILL_POS_ACTIVE]["rz"]} + self.stage.updateMetadata({model.MD_FAV_MILL_POS_ACTIVE: rotations}) + return angle + def _transformFromMeteorToSEM(self, pos: Dict[str, float]) -> Dict[str, float]: """ Transforms the current stage position from the meteor/FM imaging area @@ -744,7 +762,8 @@ def to_posture(self, pos: Dict[str, float], posture: int) -> Dict[str, float]: logging.info(f"Position Posture: {POSITION_NAMES[position_posture]}, Target Posture: {POSITION_NAMES[posture]}") - if position_posture == posture: + # The milling angle can change, so we should handle the MILLING --> MILLING posture switch differently. + if posture != MILLING and position_posture == posture: return pos # validate the transformation @@ -1642,30 +1661,21 @@ def __init__(self, microscope): self.postures = [SEM_IMAGING, FM_IMAGING] if model.MD_FAV_MILL_POS_ACTIVE in stage_md: self.postures.append(MILLING) - self._initialise_transformation(axes=["y", "z"], rotation=self.pre_tilt) + + self.linked_axes = ["y", "z"] + # The setter of self.milling_angle sets the milling angle metadata, which is used to update the transformation parameters. + self.milling_angle.subscribe(self._initialise_transformation, init=True) self.create_sample_stage() # Update the posture based on the actual metadata self._update_posture(self.stage.position.value) - def _initialise_transformation( - self, - axes: Sequence[str], - rotation: float = 0, - scale: tuple = (1, 1), - translation: tuple = (0, 0), - shear: tuple = (0, 0), - ): + def _initialise_transformation(self, angle): """ Initializes the transformation parameters that allows conversion between stage-bare and sample plane. - :param axes: stage axes which are used to calculate transformation parameters - :param rotation: rotation in radians from sample plane to stage - :param scale: scale from sample to stage - :param translation: translation from sample to stage - :param shear: shear from sample to stage """ - self._axes_dep = {"x": axes[0], "y": axes[1]} # TODO: Should be called y, z... or even better: also take x as first axis - self._update_conversion(rotation) + self._axes_dep = {"x": self.linked_axes[0], "y": self.linked_axes[1]} # TODO: Should be called y, z... or even better: also take x as first axis + self._update_conversion(self.pre_tilt) self._initialise_offset() def _update_conversion(self, @@ -1704,7 +1714,6 @@ def _update_conversion(self, tf_sem = tf_reverse @ tf_tilt @ tf_sr tf_sem_inv = numpy.linalg.inv(tf_sem) - # TODO: update MILLING transformations when changing milling angle if model.MD_FAV_MILL_POS_ACTIVE in stage_md: mill_pos_active = stage_md[model.MD_FAV_MILL_POS_ACTIVE] rx_mill = calculate_stage_tilt_from_milling_angle(milling_angle=mill_pos_active["mill_angle"], diff --git a/src/odemis/acq/test/move_tescan_test.py b/src/odemis/acq/test/move_tescan_test.py index 675cb36ff5..220d3909c1 100644 --- a/src/odemis/acq/test/move_tescan_test.py +++ b/src/odemis/acq/test/move_tescan_test.py @@ -23,7 +23,7 @@ import odemis from odemis import model -from odemis.acq.move import (FM_IMAGING, SEM_IMAGING, UNKNOWN, MicroscopePostureManager, LOADING) +from odemis.acq.move import (FM_IMAGING, SEM_IMAGING, UNKNOWN, MeteorTescan1PostureManager, LOADING, MILLING) from odemis.acq.test.move_tfs1_test import TestMeteorTFS1Move from odemis.acq.test.move_tfs3_test import TestMeteorTFS3Move from odemis.util import testing @@ -175,7 +175,7 @@ class TestMeteorTescan1FibsemMove(TestMeteorTFS3Move): def setUpClass(cls): testing.start_backend(cls.MIC_CONFIG) cls.microscope = model.getMicroscope() - cls.pm = MicroscopePostureManager(microscope=cls.microscope) + cls.pm = MeteorTescan1PostureManager(microscope=cls.microscope) # get the stage components cls.stage_bare = model.getComponent(role="stage-bare") @@ -236,5 +236,44 @@ def test_rel_move_fm_posture(self): exp_bare_pos[axis] += m_bare[axis] testing.assert_pos_almost_equal(new_bare_pos, exp_bare_pos, atol=1e-6) + def test_milling_angle_callback(self): + initial_mill_pos_md = self.stage_md.get(model.MD_FAV_MILL_POS_ACTIVE) + self.assertIsNotNone(initial_mill_pos_md) + initial_mill_angle = math.radians(10) + self.stage.updateMetadata({model.MD_FAV_MILL_POS_ACTIVE: {"rx": initial_mill_angle, "rz": initial_mill_pos_md["rz"]}}) + # Now test that when altering the milling angle VA, the metadata is automatically updated. + self.pm.milling_angle.value = math.radians(22) + altered_mill_angle_md = self.pm.stage.getMetadata().get(model.MD_FAV_MILL_POS_ACTIVE)["mill_angle"] + self.assertNotAlmostEqual(initial_mill_angle, altered_mill_angle_md, places=3) + + def test_milling_angle_stable_pos(self): + # Make sure to start from a valid position + self.pm.cryoSwitchSamplePosition(LOADING).result() + # Transform to milling posture + self.pm.cryoSwitchSamplePosition(MILLING).result() + # Store shorthand for sample stage + sample_stage = self.pm.sample_stage + # Take note of sample stage pos + initial_sample_stage_pos = sample_stage.position.value + # Switch to SEM posture + self.pm.cryoSwitchSamplePosition(SEM_IMAGING).result() + # Compare sample stage pos to previous pos + testing.assert_pos_almost_equal(sample_stage.position.value, initial_sample_stage_pos, atol=1e-6) + # Switch back to milling posture + self.pm.cryoSwitchSamplePosition(MILLING).result() + testing.assert_pos_almost_equal(sample_stage.position.value, initial_sample_stage_pos, atol=1e-6) + # TODO: uncomment the following section once all the sample stage problems are resolved + # # Now change milling angle to check stability; sample stage pos should remain the same. + # milling_angle = math.radians(30) + # self.pm.milling_angle.value = milling_angle + # # Switch to SEM posture + # self.pm.cryoSwitchSamplePosition(SEM_IMAGING).result() + # # Compare sample stage pos to previous pos + # testing.assert_pos_almost_equal(sample_stage.position.value, initial_sample_stage_pos, atol=1e-6) + # # Switch back to milling posture + # self.pm.cryoSwitchSamplePosition(MILLING).result() + # testing.assert_pos_almost_equal(sample_stage.position.value, initial_sample_stage_pos, atol=1e-6) + + if __name__ == "__main__": unittest.main() diff --git a/src/odemis/gui/cont/tabs/fibsem_tab.py b/src/odemis/gui/cont/tabs/fibsem_tab.py index 168b05cff9..5f3ba30efa 100644 --- a/src/odemis/gui/cont/tabs/fibsem_tab.py +++ b/src/odemis/gui/cont/tabs/fibsem_tab.py @@ -23,6 +23,7 @@ import logging import math from typing import Dict +from concurrent.futures import CancelledError import numpy import wx @@ -42,7 +43,7 @@ StaticSEMStream, ) from odemis.gui import conf -from odemis.gui.comp.buttons import BTN_TOGGLE_COMPLETE, BTN_TOGGLE_OFF +from odemis.gui.comp.buttons import BTN_TOGGLE_COMPLETE, BTN_TOGGLE_OFF, BTN_TOGGLE_PROGRESS from odemis.gui.conf.licences import LICENCE_FIBSEM_ENABLED, LICENCE_MILLING_ENABLED from odemis.gui.cont import milling, settings from odemis.gui.cont.features import CryoFeatureController @@ -188,15 +189,19 @@ def __init__(self, name, button, panel, main_frame, main_data): self.pm.stage.position.subscribe(self._on_stage_pos, init=True) self.panel = panel + self._posture_switch_future = model.InstantaneousFuture() + rx = self.pm.stage.getMetadata()[model.MD_FAV_MILL_POS_ACTIVE]["mill_angle"] self.panel.ctrl_milling_angle.SetValue(math.degrees(rx)) - self.panel.ctrl_milling_angle.SetValueRange(*MILLING_RANGE) + # Tilt field is in degrees, so convert from radians to integer degrees to prevent a lot of decimals from showing + self.panel.ctrl_milling_angle.SetValueRange(*numpy.round(numpy.rad2deg(MILLING_RANGE)).astype(int)) self.panel.ctrl_milling_angle.Bind(wx.EVT_COMMAND_ENTER, self._update_milling_angle) self._update_milling_angle(None) - self.panel.btn_switch_milling.Bind(wx.EVT_BUTTON, self._move_to_milling_position) - self.panel.btn_switch_sem_imaging.Bind(wx.EVT_BUTTON, self._move_to_sem) + self.panel.btn_switch_milling.Bind(wx.EVT_BUTTON, self._move_to_milling_posture) + self.panel.btn_switch_sem_imaging.Bind(wx.EVT_BUTTON, self._move_to_sem_posture) self.tab_data_model.streams.subscribe(self._on_acquired_streams) + @call_in_wx_main def _on_view(self, view): """Hide/Disable milling controls when fib view is not selected""" # is_fib_view = issubclass(view.stream_classes, FIBStream) @@ -394,68 +399,70 @@ def _on_stage_pos(self, pos: Dict[str, float]) -> None: logging.warning("Failed to update stage position label: %s", e) # update the stage position buttons - self.panel.btn_switch_sem_imaging.Enable(posture in [SEM_IMAGING, MILLING]) - self.panel.btn_switch_milling.Enable(posture in [SEM_IMAGING, MILLING]) - self.panel.ctrl_milling_angle.Enable(posture in [SEM_IMAGING, MILLING]) + if self._posture_switch_future.done(): + self.panel.btn_switch_sem_imaging.Enable(posture in [SEM_IMAGING, MILLING]) + self.panel.btn_switch_milling.Enable(posture in [SEM_IMAGING, MILLING]) + self.panel.ctrl_milling_angle.Enable(posture in [SEM_IMAGING, MILLING]) - if posture == SEM_IMAGING: - self.panel.btn_switch_sem_imaging.SetValue(BTN_TOGGLE_COMPLETE) - else: - self.panel.btn_switch_sem_imaging.SetValue(BTN_TOGGLE_OFF) - if posture == MILLING: - self.panel.btn_switch_milling.SetValue(BTN_TOGGLE_COMPLETE) - else: - self.panel.btn_switch_milling.SetValue(BTN_TOGGLE_OFF) + if posture == SEM_IMAGING: + self.panel.btn_switch_sem_imaging.SetValue(BTN_TOGGLE_COMPLETE) + else: + self.panel.btn_switch_sem_imaging.SetValue(BTN_TOGGLE_OFF) + if posture == MILLING: + self.panel.btn_switch_milling.SetValue(BTN_TOGGLE_COMPLETE) + else: + self.panel.btn_switch_milling.SetValue(BTN_TOGGLE_OFF) - self.panel.Layout() + self.panel.Layout() def _update_milling_angle(self, evt: wx.Event): + # Check if already at milling posture + already_at_milling = self.pm.getCurrentPostureLabel() == MILLING # update the metadata of the stage milling_angle = math.radians(self.panel.ctrl_milling_angle.GetValue()) - current_md = self.pm.stage.getMetadata() - self.pm.stage.updateMetadata({ - model.MD_FAV_MILL_POS_ACTIVE: {"mill_angle": milling_angle, - "rz": current_md[model.MD_FAV_MILL_POS_ACTIVE]["rz"]} - }) - + self.pm.milling_angle.value = milling_angle # this will call the setter in the move posture manager and handle md update md = self.pm.get_posture_orientation(MILLING) stage_tilt = md["rx"] self.panel.ctrl_milling_angle.SetToolTip(f"A milling angle of {math.degrees(milling_angle):.2f}° " f"corresponds to a stage tilt of {math.degrees(stage_tilt):.2f}°") - self._on_stage_pos(self.pm.stage.position.value) - - # if the tab isn't shown, we don't want to ask the user - if evt is None: # if the event is None, it means this is the initial update, dont ask the user + # if the tab isn't shown, we don't want to perform a move or change features + if evt is None: return - # changing milling angle, causes previously defined features at milling angle to be "seen" as SEM_IMAGING - # QUERY: should we update the features to the new milling angle? - box = wx.MessageDialog(self.main_frame, - message=f"Do you want to update existing feature positions with the updated milling angle ({math.degrees(milling_angle):.2f}°)?", - caption="Update existing feature positions?", - style=wx.YES_NO | wx.ICON_QUESTION | wx.CENTER) - - ans = box.ShowModal() # Waits for the window to be closed - if ans == wx.ID_YES: - logging.debug( - f"Updating existing feature positions with the updated milling angle ({math.degrees(milling_angle):.2f}°)") - # NOTE: use stage_tilt, not milling_angle - for feature in self.main_data.features.value: - milling_position = feature.get_posture_position(MILLING) - if milling_position is not None: - milling_position["rx"] = stage_tilt - feature.set_posture_position(MILLING, milling_position) - - def _move_to_milling_position(self, evt: wx.Event) -> None: - logging.info(f"MILLING ORIENTATION: {self.pm.get_posture_orientation(MILLING)}") - f = self.pm.cryoSwitchSamplePosition(MILLING) - f.result() - - def _move_to_sem(self, evt: wx.Event) -> None: - f = self.pm.cryoSwitchSamplePosition(SEM_IMAGING) - f.result() + logging.debug(f"Updating existing feature positions with the updated milling angle ({math.degrees(milling_angle):.2f}°)") + # NOTE: use stage_tilt, not milling_angle + for feature in self.main_data.features.value: + milling_position = feature.get_posture_position(MILLING) + if milling_position is not None: + milling_position["rx"] = stage_tilt + feature.set_posture_position(MILLING, milling_position) + + if already_at_milling: + # Normally this happens automatically when clicking the ProgressRadioButton, but since we are now not + # clicking it, but still want to show progress, we set the progress state of the button manually. + self.panel.btn_switch_milling.SetValue(BTN_TOGGLE_PROGRESS) + self._move_to_milling_posture(None) + + def _move_to_milling_posture(self, evt: wx.Event): + self._posture_switch_future = self.pm.cryoSwitchSamplePosition(MILLING) + + # Do NOT call f.result(). Instead, add a callback: + self._posture_switch_future.add_done_callback(self._on_move_complete) + + def _move_to_sem_posture(self, evt: wx.Event): + self._posture_switch_future = self.pm.cryoSwitchSamplePosition(SEM_IMAGING) + self._posture_switch_future.add_done_callback(self._on_move_complete) + + @call_in_wx_main + def _on_move_complete(self, future): + try: + future.result() + except CancelledError: + logging.info("Posture switch was cancelled") + except Exception: + logging.exception("Failed to switch posture") def terminate(self): self.main_data.stage.position.unsubscribe(self._on_stage_pos)