diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index c6bcb9a2..e03532a7 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -88,18 +88,18 @@ class BaseStage(lt.Thing): axis_direction = lt.ThingSetting( initial_value={"x": 1, "y": 1, "z": 1}, model=Mapping[str, int], + readonly=True, ) """Used to convert coordinates between the program frame and the hardware frame.""" def _apply_axis_direction( - self, position: Sequence[int] | Mapping[str | int] + self, position: list[int] | tuple[int] | Mapping[str | int] ) -> list[int] | Mapping[str | int]: - if isinstance(position, Sequence): - position = [ + if isinstance(position, (list, tuple)): + return [ pos * ax_dir for pos, ax_dir in zip(position, self.axis_direction.values()) ] - return dict(zip(self.axis_names, position)) if isinstance(position, Mapping): try: return { diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index a4af7731..92d75e84 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -36,6 +36,13 @@ class DummyStage(BaseStage): def __exit__(self, _exc_type, _exc_value, _traceback): """Nothing to do when the Thing context manager is closed.""" + axis_direction = lt.ThingSetting( + initial_value={"x": -1, "y": 1, "z": 1}, + model=Mapping[str, int], + readonly=True, + ) + """Used to convert coordinates between the program frame and the hardware frame.""" + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index a72150e9..3aecc6c9 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -69,6 +69,7 @@ class SangaboardThing(BaseStage): axis_direction = lt.ThingSetting( initial_value={"x": -1, "y": 1, "z": -1}, model=Mapping[str, int], + readonly=True, ) """Used to convert coordinates between the program frame and the hardware frame.""" diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index f63b44e3..571a7159 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -1,7 +1,7 @@ """Test the server without creating a full HTTP server and socket connection. Rather than spinning up a full uvicorn webserver for each test these tests use -the FastAPI ``TestClient`` or to directly communicate with the underlying +the FastAPI ``TestClient`` or directly communicate with the underlying LabThings-FastAPI code. This increases speed of testing significantly. For tests that require a full running server see the ``integration-tests`` diff --git a/tests/test_stage.py b/tests/test_stage.py new file mode 100644 index 00000000..6e457324 --- /dev/null +++ b/tests/test_stage.py @@ -0,0 +1,170 @@ +"""Test the stage without creating a full HTTP server and socket connection.""" + +from collections.abc import Mapping +import tempfile + +from fastapi.testclient import TestClient +import pytest +from httpx import HTTPStatusError + +import labthings_fastapi as lt +from labthings_fastapi.exceptions import NotConnectedToServerError + +from openflexure_microscope_server.things.stage import ( + BaseStage, + RedefinedBaseMovementError, +) +from openflexure_microscope_server.things.stage.dummy import DummyStage + + +@pytest.fixture +def dummy_stage(): + """Return a dummy stage with a very low step time.""" + return DummyStage(step_time=0.000001) + + +@pytest.fixture +def thing_server(dummy_stage): + """Yield a server with a very basic configuration.""" + with tempfile.TemporaryDirectory() as tmpdir: + server = lt.ThingServer(settings_folder=tmpdir) + server.add_thing(dummy_stage, "/stage/") + yield server + + +@pytest.fixture +def stage_client(thing_server): + """Yield a labthings ThingClient for the stage.""" + with TestClient(thing_server.app) as test_client: + yield lt.ThingClient.from_url("/stage/", client=test_client) + + +def test_override_base_movement(): + """Child classes of stage should implement functions in the hardware reference frame. + + ``move_absolute`` and ``move_relative`` are in the program reference frame and + convert to the hardware frame. It is recommended to override + ``_hardware_move_relative`` and ``_hardware_move_absolute`` instead. + + Check that the expected error is raised if the methods are overridden. + """ + + class BadStage1(BaseStage): + @lt.thing_action + def move_relative( + self, + cancel: lt.deps.CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): + pass + + with pytest.raises(RedefinedBaseMovementError): + BadStage1() + + class BadStage2(BaseStage): + @lt.thing_action + def move_absolute( + self, + cancel: lt.deps.CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): + pass + + with pytest.raises(RedefinedBaseMovementError): + BadStage2() + + +def _set_axis_direction(dummy_stage, direction): + """Set the axis direction directly even if not connected to server.""" + try: + dummy_stage.axis_direction = direction + except NotConnectedToServerError: + pass + + +def test_apply_axis_direction_all_pos(dummy_stage): + """Test the apply axis direction function behaves as expected when axis +v3.""" + # Directly create a stage not through a ThingServer to access private methods + _set_axis_direction(dummy_stage, {"x": 1, "y": 1, "z": 1}) + + # A list of positions to try + positions = [ + [1, 2, 3], # list + {"x": 1, "y": 2, "z": 3}, # mapping + {"x": 1, "z": 2, "y": 3}, # mapping out of order + {"x": 3}, # Mapping with only 1 value + {"x": 1, "z": 2}, # Mapping with only 2 values + ] + for pos in positions: + assert dummy_stage._apply_axis_direction(pos) == pos + + # Check tuple separately as it gets converted to list + assert dummy_stage._apply_axis_direction((1, 2, 0)) == [1, 2, 0] + + +def test_apply_axis_direction_mixed(dummy_stage): + """Test the apply axis direction function behaves as expected when axis dirs are mixed.""" + # Make x and z negative + _set_axis_direction(dummy_stage, {"x": -1, "y": 1, "z": -1}) + + # A list of (input position, output position) to try + position_pairs = [ + ([1, 2, 3], [-1, 2, -3]), # list + ((1, 2, 0), [-1, 2, 0]), # tuple (gets converted to list) + ({"x": 1, "y": 2, "z": 3}, {"x": -1, "y": 2, "z": -3}), # mapping + ({"x": 1, "z": 2, "y": 3}, {"x": -1, "z": -2, "y": 3}), # mapping out of order + ({"x": 3}, {"x": -3}), # Mapping with only 1 value + ({"x": 1, "z": 2}, {"x": -1, "z": -2}), # Mapping with only 2 values + ] + for pos, expected_pos in position_pairs: + assert dummy_stage._apply_axis_direction(pos) == expected_pos + + +def test_apply_axis_errors(dummy_stage): + """Test the apply axis direction returns appropriate errors.""" + with pytest.raises(TypeError): + dummy_stage._apply_axis_direction(None) + with pytest.raises(TypeError): + dummy_stage._apply_axis_direction("Onwards!") + with pytest.raises(KeyError): + dummy_stage._apply_axis_direction({"x": -1, "y": 2, "z": 4, "up": -3}) + with pytest.raises(KeyError): + dummy_stage._apply_axis_direction({"x": -1, "y": 2, "up": -3}) + + +def test_default_values(stage_client, dummy_stage): + """Check the default values for the dummy stage.""" + # axes are x, y, z (note that going through the thing, client the tuple is + # converted to a list. + assert stage_client.axis_names == ["x", "y", "z"] + # position starts at 0, 0, 0 + assert stage_client.position == {"x": 0, "y": 0, "z": 0} + # axis direction starts is -1, 1, 1 for the dummy stage + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + # And check the thing state + assert dummy_stage.thing_state == {"position": {"x": 0, "y": 0, "z": 0}} + + +def test_direction_inversion(stage_client): + """Check axes invert as expected when called.""" + # Can't set an arbitrary value via a client as read only: + with pytest.raises(HTTPStatusError) as excinfo: + stage_client.axis_direction = {"x": 2, "y": 1, "z": 1} + # Read only should set a 405 error code + assert excinfo.value.response.status_code == 405 + # And not modify th initial value + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="x") + assert stage_client.axis_direction == {"x": 1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="x") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="y") + assert stage_client.axis_direction == {"x": -1, "y": -1, "z": 1} + stage_client.invert_axis_direction(axis="y") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="z") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": -1} + stage_client.invert_axis_direction(axis="z") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1}