From ffb9a4d5097e0402f7c433718045e848999e4533 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 4 Mar 2026 13:12:43 +0000 Subject: [PATCH 1/6] Add CChip workflow to scan_workflows --- ofm_config_full.json | 1 + ofm_config_simulation.json | 1 + .../things/scan_workflows.py | 152 ++++++++++++++++-- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/ofm_config_full.json b/ofm_config_full.json index 27024a2c..47fd7e47 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -17,6 +17,7 @@ "default_workflow": "histo_scan_workflow" } }, + "CChip_workflow": "openflexure_microscope_server.things.scan_workflows:CChipWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow", "raster_workflow": "openflexure_microscope_server.things.scan_workflows:RasterWorkflow", diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json index b04988d4..dbc246f7 100644 --- a/ofm_config_simulation.json +++ b/ofm_config_simulation.json @@ -12,6 +12,7 @@ "default_workflow": "histo_scan_workflow" } }, + "CChip_workflow": "openflexure_microscope_server.things.scan_workflows:CChipWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow", "raster_workflow": "openflexure_microscope_server.things.scan_workflows:RasterWorkflow", diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 1bf5b826..93a3d8bc 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -32,6 +32,7 @@ from openflexure_microscope_server.things.autofocus import ( AutofocusParams, AutofocusThing, SmartStackParams, + StackParams, ) from openflexure_microscope_server.things.background_detect import ( ChannelDeviationLUV, @@ -666,7 +667,14 @@ class RegularGridSettingsModel(RectGridSettingsModel): style: Literal["snake", "raster"] -class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): +RegGridSettingModelType = TypeVar( + "RegGridSettingModelType", bound=RegularGridSettingsModel +) + + +class RegularGridWorkflow( + RectGridWorkflow[RegGridSettingModelType], Generic[RegGridSettingModelType] +): """A base workflow for any workflow that uses a regular rectangular grid.""" x_count: int = lt.setting(default=3, ge=1) @@ -674,20 +682,20 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): y_count: int = lt.setting(default=2, ge=1) """The number of rows in the scan.""" - _settings_model = RegularGridSettingsModel + _settings_model: type[RegGridSettingModelType] _planner_cls = RegularGridPlanner _grid_style: Literal["snake", "raster"] - def _build_scan_settings(self, base_kwargs: dict) -> RegularGridSettingsModel: + def _build_scan_settings(self, base_kwargs: dict) -> RegGridSettingModelType: """Construct the SettingModel for all_settings.""" - return RegularGridSettingsModel( + return self._settings_model( **base_kwargs, x_count=self.x_count, y_count=self.y_count, style=self._grid_style, ) - def pre_scan_routine(self, settings: RegularGridSettingsModel) -> None: + def pre_scan_routine(self, settings: RegGridSettingModelType) -> None: """Perform these steps before starting the scan. In this case, only autofocus. @@ -699,11 +707,11 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): ) def new_scan_planner( - self, settings: RegularGridSettingsModel, position: Mapping[str, int] + self, settings: RegGridSettingModelType, position: Mapping[str, int] ) -> ScanPlanner: """Return a new scan planner object. - :param settings: The settings for this scan as a SnakeSettingsModel + :param settings: The settings for this scan as the relevant SettingsModel type. :param position: The starting position as a mapping of axes names to int. """ planner_settings = { @@ -719,11 +727,11 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): ) def acquisition_routine( - self, settings: RegularGridSettingsModel, xyz_pos: tuple[int, int, int] + self, settings: RegGridSettingModelType, xyz_pos: tuple[int, int, int] ) -> tuple[bool, Optional[int]]: """Autofocus and capture. - :param settings: The settings for this scan as a RegularGridSettingsModel + :param settings: The settings for this scan as the relevant SettingsModel type. :param xyz_pos: The current position as a tuple or 3 ints. :return: A tuple of whether an image was taken, and the z-position for focus. If failed to find focus, returns for the focus z-position. @@ -762,7 +770,7 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): ) -class SnakeWorkflow(RegularGridWorkflow): +class SnakeWorkflow(RegularGridWorkflow[RegularGridSettingsModel]): """A workflow optimised for snaking around samples. This workflow generates a list of coordinates in a rectangle, and snakes @@ -777,10 +785,11 @@ class SnakeWorkflow(RegularGridWorkflow): ), readonly=True, ) + _settings_model = RegularGridSettingsModel _grid_style = "snake" -class RasterWorkflow(RegularGridWorkflow): +class RasterWorkflow(RegularGridWorkflow[RegularGridSettingsModel]): """A workflow optimised for snaking around samples. This workflow generates a list of coordinates in a rectangle, and always @@ -796,5 +805,124 @@ class RasterWorkflow(RegularGridWorkflow): ), readonly=True, ) - + _settings_model = RegularGridSettingsModel _grid_style = "raster" + + +class CChipScanSettingsModel(RegularGridSettingsModel): + """The settings for a scan with the CChipWorkflow. + + This includes settings calculated when starting. This will be held by smart scan + during a scan and serialised to disk. + """ + + stack_params: StackParams + + +class CChipWorkflow(RegularGridWorkflow[CChipScanSettingsModel]): + """A workflow optimised for scanning the well of a CChip. + + This workflow generates a list of coordinates in a rectangle, and snakes + around them from the top left (assuming positive dx and dy), stacking the + grid and above. + """ + + display_name: str = lt.property(default="C-Chip Scan", readonly=True) + ui_blurb: str = lt.property( + default=( + "This scan workflow is optimised for scanning a C-Chip. It focuses on " + "a grid, then stacks images above the grid to complete a volumetric scan." + ), + readonly=True, + ) + _grid_style = "snake" + _settings_model = CChipScanSettingsModel + + overlap: float = lt.setting(default=0.1, ge=0.1, le=0.7) + """The fraction that adjacent images should overlap in x and y. + + This must be between 0.1 and 0.7. + """ + x_count: int = lt.setting(default=5, readonly=False) + """The number of columns in the scan.""" + y_count: int = lt.setting(default=7, readonly=False) + """The number of rows in the scan.""" + + stack_images_to_save: int = lt.setting(default=9, readonly=False) + """The number of images to save in a stack. + + Defaults to 1 unless you need to see either side of focus + """ + + stack_dz: int = lt.setting(default=500, readonly=False) + """Distance in steps between images in a z-stack.""" + + def create_stack_params( + self, + ) -> StackParams: + """Set up the parameters used for all stacks in a scan. + + :returns: A StackSmartParams object with the required parameters. + """ + return StackParams( + stack_dz=self.stack_dz, images_to_save=self.stack_images_to_save + ) + + def _build_scan_settings(self, base_kwargs: dict) -> CChipScanSettingsModel: + """Construct the SettingModel for all_settings.""" + stack_params = self.create_stack_params() + return self._settings_model( + **base_kwargs, + x_count=self.x_count, + y_count=self.y_count, + style=self._grid_style, + stack_params=stack_params, + ) + + def pre_scan_routine(self, settings: CChipScanSettingsModel) -> None: + """No autofocus, a looping autofocus on a CChip could corrupt the entire scan.""" + pass + + def acquisition_routine( + self, + settings: CChipScanSettingsModel, + xyz_pos: tuple[int, int, int], # noqa: ARG002 + ) -> tuple[bool, Optional[int]]: + """Autofocus and capture a z-stack starting at the focused position. + + The routine performs a fast autofocus using the provided ``dz``. + The focused z-height becomes the starting position for the stack + and is also the height of the first captured image. + + A stack of ``self.stack_images_to_save`` images is then acquired. + Each subsequent image is captured after moving the stage upward + by ``self.stack_dz`` steps in z. + + :param xyz_pos: The (x, y, z) position associated with this acquisition. + :param settings: The settings for this scan as a CChipSettingsModel + + :return: (True, focus_height) where focus_height is the autofocus + z-position and the height of the first image in the stack. + """ + # Perform autofocus + self._autofocus.fast_autofocus(dz=settings.autofocus_params.dz) + focus_height = self._stage.get_xyz_position()[2] + self._autofocus.run_basic_stack(settings.stack_params, settings.capture_params) + + return True, focus_height + + @lt.property + def settings_ui(self) -> list[PropertyControl]: + """A list of PropertyControl objects to create the settings in the scan tab.""" + return [ + property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"), + property_control_for(self, "x_count", label="Number of columns"), + property_control_for(self, "y_count", label="Number of rows"), + property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"), + property_control_for( + self, "stack_dz", label="Distance in z between images in stack (steps)" + ), + property_control_for( + self, "stack_images_to_save", label="Images to save per xy site" + ), + ] From b43150c8bbb5ecd6c690d2daaffd792da215151c Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 4 Mar 2026 18:43:41 +0000 Subject: [PATCH 2/6] Remove backlash correction from basic stack, as this is handled by move rel --- .../things/autofocus.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 909ff229..9bb4b801 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -67,11 +67,6 @@ class StackParams(BaseModel): settling_time: float = Field(default=0.3, ge=0) """Time (in seconds) between moving and capturing an image""" - backlash_correction: int = 250 - """ - Distance (in steps) to overshoot a move and then undo, to account for backlash - """ - origin: StackOrigin = StackOrigin.START """Where the stack is positioned relative to the current z position.""" @@ -760,7 +755,7 @@ class AutofocusThing(lt.Thing): logic, or autofocus is performed. :param stack_parameters: StackParams defining stack spacing, - image count and backlash correction. + and image count. :param capture_parameters: CaptureParams defining save resolution, images directory. @@ -781,14 +776,7 @@ class AutofocusThing(lt.Thing): else: target_offset = 0 - # Apply backlash correction: overshoot and move back - overshoot = target_offset - stack_parameters.backlash_correction - if overshoot != 0: - self._stage.move_relative(z=overshoot) - - # Move back to starting point if needed - if stack_parameters.backlash_correction != 0: - self._stage.move_relative(z=stack_parameters.backlash_correction) + self._stage.move_relative(z=target_offset) # Capture images_to_save images for move_count in range(stack_parameters.images_to_save): From 5ce77538d160b04472ec6c72d20d852b9b412960 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Tue, 10 Mar 2026 15:04:42 +0000 Subject: [PATCH 3/6] Fix tests now we dont do extra backlash moves --- .../things/autofocus.py | 4 +- tests/unit_tests/test_stack.py | 96 ------------------- 2 files changed, 2 insertions(+), 98 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 9bb4b801..5cbc050c 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -775,8 +775,8 @@ class AutofocusThing(lt.Thing): target_offset = -total_range else: target_offset = 0 - - self._stage.move_relative(z=target_offset) + if target_offset != 0: + self._stage.move_relative(z=target_offset) # Capture images_to_save images for move_count in range(stack_parameters.images_to_save): diff --git a/tests/unit_tests/test_stack.py b/tests/unit_tests/test_stack.py index 6adf2df7..6e022143 100644 --- a/tests/unit_tests/test_stack.py +++ b/tests/unit_tests/test_stack.py @@ -694,7 +694,6 @@ def test_run_basic_stack_simple( stack_dz=10, images_to_save=3, settling_time=0, - backlash_correction=0, origin=StackOrigin.START, ) @@ -757,7 +756,6 @@ def test_run_basic_stack_center_origin( stack_dz=10, images_to_save=5, settling_time=0, - backlash_correction=0, origin=StackOrigin.CENTER, ) @@ -804,97 +802,6 @@ def test_run_basic_stack_center_origin( assert final_z == expected_final_z, "Final Z position is incorrect" -def test_run_basic_stack_backlash_applied( - autofocus_thing, mocker, fake_capture, fake_move_relative -): - """Backlash correction should overshoot first, then move back before starting stack.""" - stack_params = StackParams( - stack_dz=10, - images_to_save=3, - settling_time=0, - backlash_correction=50, - origin=StackOrigin.START, - ) - - capture_params = mocker.Mock() - capture_params.images_dir = "dummy" - capture_params.save_resolution = (100, 100) - - start_z = 0 - autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z} - - autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture) - autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative) - autofocus_thing._cam.save_from_memory = mocker.Mock() - autofocus_thing._cam.clear_buffers = mocker.Mock() - - autofocus_thing.run_basic_stack(stack_params, capture_params) - - calls = autofocus_thing._stage.move_relative.call_args_list - - # First move is overshoot for backlash - assert calls[0].kwargs["z"] == -stack_params.backlash_correction, ( - "Backlash overshoot not applied correctly" - ) - - # Second move corrects back to starting point - assert calls[1].kwargs["z"] == stack_params.backlash_correction, ( - "Backlash correction move not applied correctly" - ) - - -def test_run_basic_stack_backlash_and_offset( - autofocus_thing, mocker, fake_capture, fake_move_relative -): - """Backlash correction and stack offset both applied. - - Stack should begin by moving down by half the height of the stack, - plus backlash correction, then move up by backlash correction, then run the basic stack. - """ - stack_params = StackParams( - stack_dz=100, - images_to_save=3, - settling_time=0, - backlash_correction=50, - origin=StackOrigin.CENTER, - ) - - capture_params = mocker.Mock() - capture_params.images_dir = "dummy" - capture_params.save_resolution = (100, 100) - - start_z = 0 - autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z} - - autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture) - autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative) - autofocus_thing._cam.save_from_memory = mocker.Mock() - autofocus_thing._cam.clear_buffers = mocker.Mock() - - autofocus_thing.run_basic_stack(stack_params, capture_params) - - calls = autofocus_thing._stage.move_relative.call_args_list - - # Combined initial offset + backlash overshoot - total_stack_range = stack_params.stack_dz * (stack_params.images_to_save - 1) - expected_first_move = -(total_stack_range // 2 + stack_params.backlash_correction) - assert calls[0].kwargs["z"] == expected_first_move, ( - "Combined overshoot not applied correctly" - ) - - # Backlash correction back to base of stack - assert calls[1].kwargs["z"] == stack_params.backlash_correction, ( - "Backlash correction move not applied correctly" - ) - - # Subsequent moves in stack - expected_stack_moves = [stack_params.stack_dz] * (stack_params.images_to_save - 1) - actual_stack_moves = [c.kwargs["z"] for c in calls[2:]] - assert actual_stack_moves == expected_stack_moves, ( - "Stack moves after backlash/offset not correct" - ) - - def test_run_basic_stack_end_origin( autofocus_thing, mocker, fake_capture, fake_move_relative ): @@ -903,7 +810,6 @@ def test_run_basic_stack_end_origin( stack_dz=10, images_to_save=4, settling_time=0, - backlash_correction=0, origin=StackOrigin.END, ) @@ -948,7 +854,6 @@ def test_invalid_stack_images_raises(): stack_dz=10, images_to_save=capture_count, settling_time=0, - backlash_correction=0, origin=StackOrigin.START, ) @@ -960,7 +865,6 @@ def test_invalid_stack_settling_raises(): stack_dz=10, images_to_save=1, settling_time=-1, - backlash_correction=0, origin=StackOrigin.START, ) From 68c9f76fc08fbbd6d08000c4de85abaea85d89b3 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 12 Mar 2026 16:11:41 +0000 Subject: [PATCH 4/6] Fix missing import --- src/openflexure_microscope_server/things/scan_workflows.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 93a3d8bc..6ce8e42b 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -44,6 +44,7 @@ from openflexure_microscope_server.ui import ( UI_ELEMENT_RESPONSE, Accordion, HeaderBlock, + PropertyControl, TextBlock, UIElementList, action_button_for, From 2675dff8dcec16e7680269691aa4fe7010f547ef Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 12 Mar 2026 17:04:48 +0000 Subject: [PATCH 5/6] Edit ofm_config_full.json --- ofm_config_full.json | 1 - 1 file changed, 1 deletion(-) diff --git a/ofm_config_full.json b/ofm_config_full.json index 47fd7e47..27024a2c 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -17,7 +17,6 @@ "default_workflow": "histo_scan_workflow" } }, - "CChip_workflow": "openflexure_microscope_server.things.scan_workflows:CChipWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow", "raster_workflow": "openflexure_microscope_server.things.scan_workflows:RasterWorkflow", From e5b042874021211f57ea36b2181b95a45fb97775 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 12 Mar 2026 17:05:15 +0000 Subject: [PATCH 6/6] Edit ofm_config_simulation.json --- ofm_config_simulation.json | 1 - 1 file changed, 1 deletion(-) diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json index dbc246f7..b04988d4 100644 --- a/ofm_config_simulation.json +++ b/ofm_config_simulation.json @@ -12,7 +12,6 @@ "default_workflow": "histo_scan_workflow" } }, - "CChip_workflow": "openflexure_microscope_server.things.scan_workflows:CChipWorkflow", "histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow", "snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow", "raster_workflow": "openflexure_microscope_server.things.scan_workflows:RasterWorkflow",