diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 58563457..2f237d04 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -145,8 +145,8 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): display_name: str = lt.property(default="Histo Scan", readonly=True) ui_blurb: str = lt.property( default=( - "This scan workflow is optimised for scanning H&E stained biopsies. It" - "spirals out from the starting location, scanning only where it detects" + "This scan workflow is optimised for scanning H&E stained biopsies. It " + "spirals out from the starting location, scanning only where it detects " "sample. It also works well for many other flat, well-featured samples." ), readonly=True, @@ -308,8 +308,8 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]): # Coerce to positive integer, but correct if x and y are flipped if abs(x_move_stage["x"]) > abs(x_move_stage["y"]): return x_move_stage["x"], y_move_stage["y"] - # If not use the other stage axes - return x_move_stage["y"], y_move_stage["x"] + # If not use the other stage axes. Note "dx" will be the movement in camera y. + return y_move_stage["x"], x_move_stage["y"] def create_smart_stack_params( self, diff --git a/tests/unit_tests/test_scan_workflows.py b/tests/unit_tests/test_scan_workflows.py new file mode 100644 index 00000000..705e01cc --- /dev/null +++ b/tests/unit_tests/test_scan_workflows.py @@ -0,0 +1,211 @@ +"""Tests for ScanWorkflow things.""" + +import itertools +import logging + +import pytest +from pydantic import BaseModel + +import labthings_fastapi as lt +from labthings_fastapi.testing import create_thing_without_server + +from openflexure_microscope_server.stitching import StitchingSettings +from openflexure_microscope_server.things.autofocus import SmartStackParams +from openflexure_microscope_server.things.camera_stage_mapping import csm_img_to_stage +from openflexure_microscope_server.things.scan_workflows import ( + HistoScanSettingsModel, + HistoScanWorkflow, + ScanWorkflow, +) +from openflexure_microscope_server.ui import PropertyControl + + +def test_partial_base_classes(): + """Create a partial class and check it raises the correct errors.""" + + class MinimalSetings(BaseModel): + """Some minimal settings for a workflow that doesn't work.""" + + foo: str = "bar" + + class BadWorkflow(ScanWorkflow[MinimalSetings]): + """Can initialise. Other properties and methods error.""" + + display_name: str = lt.property(default="Bad Workflow", readonly=True) + + bad_workflow = create_thing_without_server(BadWorkflow) + + settings = MinimalSetings() + with pytest.raises(NotImplementedError): + bad_workflow.check_before_start(settings) + + with pytest.raises(NotImplementedError): + bad_workflow.ready + + with pytest.raises(NotImplementedError): + bad_workflow.all_settings("Fake Dir") + + with pytest.raises(NotImplementedError): + bad_workflow.pre_scan_routine(settings) + + with pytest.raises(NotImplementedError): + bad_workflow.new_scan_planner(settings, position={"x": 0, "y": 0, "z": 0}) + + with pytest.raises(NotImplementedError): + bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0]) + + with pytest.raises(NotImplementedError): + bad_workflow.settings_ui + + +@pytest.fixture +def histo_workflow(): + """Return a HistoScanWorkflow thing with slots mocked.""" + return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True) + + +# Use itertools to iterate over every true/false permutation +@pytest.mark.parametrize( + ("csm_calibrated", "skip_background", "bg_ready"), + itertools.product([True, False], repeat=3), +) +def test_histo_workflow_ready( + histo_workflow, csm_calibrated, skip_background, bg_ready, caplog +): + """Check ready property and the pre-run check work for each permutation.""" + histo_workflow._csm.calibration_required = not csm_calibrated + histo_workflow.skip_background = skip_background + histo_workflow._background_detector.ready = bg_ready + + if not csm_calibrated: + # For all permutations not ready if CSM isn't calibrated. + assert not histo_workflow.ready + with pytest.raises( + RuntimeError, match="Camera Stage Mapping is not calibrated." + ): + histo_workflow.check_before_start("scan_name") + elif not skip_background: + # CSM is ready and background is not skipped: Always ready, but warns on check + assert histo_workflow.ready + with caplog.at_level(logging.WARNING): + histo_workflow.check_before_start("scan_name") + assert len(caplog.records) == 1 + elif not bg_ready: + # Skipping background, but detector not ready. Raises error + assert not histo_workflow.ready + with pytest.raises(RuntimeError, match="Background is not set"): + histo_workflow.check_before_start("scan_name") + else: + # Finally CSM calibrated, skipping background, and detector ready: This is + # ready and should not warn. + assert histo_workflow.ready + with caplog.at_level(logging.WARNING): + histo_workflow.check_before_start("scan_name") + assert len(caplog.records) == 0 + + +def test_histo_workflow_settings_generation(histo_workflow, mocker): + """Check the settings models generate as expected.""" + mocker.patch.object( + histo_workflow, "_calc_displacement_from_overlap", return_value=(123, 456) + ) + workflow_settings, stitching_settings = histo_workflow.all_settings("/this/img_dir") + ## Check type + assert isinstance(workflow_settings, HistoScanSettingsModel) + assert isinstance(stitching_settings, StitchingSettings) + assert isinstance(workflow_settings.smart_stack_params, SmartStackParams) + + # Check stitching defaults + assert stitching_settings.correlation_resize == 0.5 + assert stitching_settings.overlap == 0.45 + # Check some workflow defaults + assert workflow_settings.overlap == 0.45 + assert workflow_settings.max_dist == 45000 + assert workflow_settings.skip_background + assert workflow_settings.smart_stack_params.stack_dz == 50 + assert workflow_settings.smart_stack_params.images_to_save == 1 + assert workflow_settings.smart_stack_params.min_images_to_test == 9 + # Check values from calculating overlap are as expected (from above mock) + assert workflow_settings.dx == 123 + assert workflow_settings.dy == 456 + # And that the input image dir is passed to stack the stack parameter for saving + assert workflow_settings.smart_stack_params.images_dir == "/this/img_dir" + + +# A CSM that is "normal" changing from camera maxtrix coordinates (y,x) to normal +# (x, y) coordinates +CSM_NORMAL = [ + [0.02, 0.25], + [0.25, 0.01], +] +# A CSM that is "normal" changing from camera maxtrix coordinates (y,x) to normal +# (x, y) coordinates, but the output y sign is flipped. +CSM_FLIP_Y = [ + [0.02, -0.25], + [0.25, 0.01], +] +# A CSM that is for a rotated compared to normal camera changing from camera maxtrix +# thus dx (the x motor) controls y and the y motor controls x +CSM_ROTATED = [ + [0.25, 0.02], + [0.01, 0.25], +] + + +@pytest.mark.parametrize( + ("csm_matrix", "overlap", "expected_steps"), + [ + (CSM_NORMAL, 0.5, (100, 75)), + (CSM_NORMAL, 0.25, (150, 112)), + (CSM_FLIP_Y, 0.5, (100, -75)), + (CSM_FLIP_Y, 0.25, (150, -112)), + (CSM_ROTATED, 0.5, (75, 100)), + (CSM_ROTATED, 0.25, (112, 150)), + ], +) +def test_histo_calculate_overlap(csm_matrix, overlap, expected_steps, histo_workflow): + """Check the dx and dy values are as expected for given overlap and matrix. + + Note that the matrices on the dominant axes all have 0.25 magnitude. And the + mock camera is set to be 800px in x and 600px in y. For 0 overlap the movement + should be dx of 200 (due to the 0.25 factor). + """ + + def apply_csm(x: float, y: float, **_kwargs: float) -> dict[str, int]: + """Convert image coordinates to stage coordinates.""" + return csm_img_to_stage(csm_matrix, x=x, y=y) + + histo_workflow._csm.convert_image_to_stage_coordinates.side_effect = apply_csm + + # First check this error if CSM isn't calibrated: + histo_workflow._csm.calibration_required = True + histo_workflow._csm.image_resolution = None + with pytest.raises(RuntimeError, match="CSM not set"): + histo_workflow._calc_displacement_from_overlap(0.25) + + histo_workflow._csm.calibration_required = False + # the img resolution is in matrix coords, so (y, x) not (x, y) + histo_workflow._csm.image_resolution = (600, 800) + dx, dy = histo_workflow._calc_displacement_from_overlap(overlap) + assert (dx, dy) == expected_steps + + +def test_histo_workflow_settings_ui(histo_workflow): + """Check that the workflow specifies the expected controls.""" + ui = histo_workflow.settings_ui + + assert len(ui) == 7 + for element in ui: + assert isinstance(element, PropertyControl) + + names = [el.property_name for el in ui] + expected_names = [ + "overlap", + "skip_background", + "stack_images_to_save", + "stack_min_images_to_test", + "stack_dz", + "autofocus_dz", + "max_range", + ] + assert names == expected_names diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index 14220f85..49d9223d 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -53,7 +53,7 @@ def smart_scan_thing(): SmartScanThing, scans_folder=SCAN_DIR, default_workflow="mock-_all_workflows", - mock_all_slots=True + mock_all_slots=True, )